diff options
| author | Fuwn <[email protected]> | 2026-02-08 07:07:59 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-08 07:07:59 -0800 |
| commit | a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1 (patch) | |
| tree | 7d44bdcb94cc1b69fbc201a4757f27f3751c5adb /apps/web/app/shared/[token] | |
| parent | chore: gate Vercel analytics and speed insights to production only (diff) | |
| download | asa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.tar.xz asa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.zip | |
feat: share with highlighted excerpt and fix auth redirect URLs
Add "share" button to text selection toolbar so users can share an entry
with a highlighted passage visible to visitors. The public share page
renders the highlight and scrolls to it on load.
Also fix magic link and password reset redirects to use NEXT_PUBLIC_APP_URL
instead of window.location.origin so emails link to the production domain.
Diffstat (limited to 'apps/web/app/shared/[token]')
| -rw-r--r-- | apps/web/app/shared/[token]/page.tsx | 51 | ||||
| -rw-r--r-- | apps/web/app/shared/[token]/shared-entry-content.tsx | 57 |
2 files changed, 92 insertions, 16 deletions
diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx index 222c1c8..7c7a463 100644 --- a/apps/web/app/shared/[token]/page.tsx +++ b/apps/web/app/shared/[token]/page.tsx @@ -1,14 +1,28 @@ import type { Metadata } from "next" import { createSupabaseAdminClient } from "@/lib/supabase/admin" import { sanitizeEntryContent } from "@/lib/sanitize" +import { SharedEntryContent } from "./shared-entry-content" interface SharedPageProperties { params: Promise<{ token: string }> } +interface SharedHighlightData { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + interface SharedEntryRow { entry_id: string expires_at: string | null + highlighted_text: string | null + highlight_text_offset: number | null + highlight_text_length: number | null + highlight_text_prefix: string | null + highlight_text_suffix: string | null entries: { id: string title: string | null @@ -30,7 +44,7 @@ async function fetchSharedEntry(token: string) { const { data, error } = await adminClient .from("shared_entries") .select( - "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + "entry_id, expires_at, highlighted_text, highlight_text_offset, highlight_text_length, highlight_text_prefix, highlight_text_suffix, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" ) .eq("share_token", token) .maybeSingle() @@ -43,7 +57,22 @@ async function fetchSharedEntry(token: string) { return { expired: true as const } } - return { expired: false as const, entry: row.entries } + let highlightData: SharedHighlightData | null = null + if ( + row.highlighted_text && + row.highlight_text_offset !== null && + row.highlight_text_length !== null + ) { + highlightData = { + highlightedText: row.highlighted_text, + textOffset: row.highlight_text_offset, + textLength: row.highlight_text_length, + textPrefix: row.highlight_text_prefix ?? "", + textSuffix: row.highlight_text_suffix ?? "", + } + } + + return { expired: false as const, entry: row.entries, highlightData } } export async function generateMetadata({ @@ -67,18 +96,6 @@ export async function generateMetadata({ } } -function SanitisedContent({ htmlContent }: { htmlContent: string }) { - // Content is sanitised via sanitize-html before rendering - const sanitisedHtml = sanitizeEntryContent(htmlContent) - return ( - <div - className="prose-reader text-text-secondary" - // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html - dangerouslySetInnerHTML={{ __html: sanitisedHtml }} - /> - ) -} - export default async function SharedPage({ params }: SharedPageProperties) { const { token } = await params const result = await fetchSharedEntry(token) @@ -106,6 +123,7 @@ export default async function SharedPage({ params }: SharedPageProperties) { } const entry = result.entry + const sanitisedHtml = sanitizeEntryContent(entry.content_html || entry.summary || "") const formattedDate = entry.published_at ? new Date(entry.published_at).toLocaleDateString("en-GB", { day: "numeric", @@ -133,8 +151,9 @@ export default async function SharedPage({ params }: SharedPageProperties) { /> </div> )} - <SanitisedContent - htmlContent={entry.content_html || entry.summary || ""} + <SharedEntryContent + sanitisedHtml={sanitisedHtml} + highlightData={result.highlightData} /> </article> <footer className="mt-12 border-t border-border pt-4 text-text-dim"> diff --git a/apps/web/app/shared/[token]/shared-entry-content.tsx b/apps/web/app/shared/[token]/shared-entry-content.tsx new file mode 100644 index 0000000..aaa9892 --- /dev/null +++ b/apps/web/app/shared/[token]/shared-entry-content.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + deserializeHighlightRange, + applyHighlightToRange, +} from "@/lib/highlight-positioning" + +interface SharedHighlightData { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + +interface SharedEntryContentProperties { + sanitisedHtml: string + highlightData: SharedHighlightData | null +} + +export function SharedEntryContent({ + sanitisedHtml, + highlightData, +}: SharedEntryContentProperties) { + const containerReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + const container = containerReference.current + if (!container) return + + container.innerHTML = sanitisedHtml + + if (!highlightData) return + + const highlightRange = deserializeHighlightRange(container, highlightData) + if (!highlightRange) return + + applyHighlightToRange(highlightRange, "shared-highlight", "yellow", false) + + requestAnimationFrame(() => { + const markElement = container.querySelector( + 'mark[data-highlight-identifier="shared-highlight"]' + ) + if (markElement) { + markElement.scrollIntoView({ behavior: "smooth", block: "center" }) + } + }) + }, [sanitisedHtml, highlightData]) + + return ( + <div + ref={containerReference} + className="prose-reader text-text-secondary" + /> + ) +} |